<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>节点动态展开收起</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      font-family: Arial, sans-serif;
    }
    svg {
      width: 100vw;
      height: 100vh;
      background-color: #f0f2f5;
    }
    .link {
      stroke: #999;
      stroke-opacity: 0.6;
      stroke-width: 1.5px;
    }
    .node circle {
      stroke: #333;
      stroke-width: 1.5px;
      cursor: pointer;
      transition: all 0.3s;
    }
    .node text {
      pointer-events: none;
      font-size: 14px;
      fill: #333;
    }
  </style>
</head>
<body>

<svg></svg>

<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
  // 初始数据
  const data = {
    id: 'root',
    name: '根节点',
    children: [
      {
        id: 'child1',
        name: '子节点 1',
        children: [
          { id: 'child1-1', name: '子节点 1-1' },
          { id: 'child1-2', name: '子节点 1-2' }
        ]
      },
      {
        id: 'child2',
        name: '子节点 2',
        children: [
          { id: 'child2-1', name: '子节点 2-1' }
        ]
      }
    ]
  };

  const width = window.innerWidth;
  const height = window.innerHeight;

  const svg = d3.select('svg')
          .call(d3.zoom().on("zoom", (event) => {
            g.attr("transform", event.transform);
          }))
          .append('g');

  const g = svg.append('g');

  const simulation = d3.forceSimulation()
          .force('link', d3.forceLink().id(d => d.id).distance(120))
          .force('charge', d3.forceManyBody().strength(-500))
          .force('center', d3.forceCenter(width / 2, height / 2));

  let nodes = [], links = [];

  function update() {
    nodes = [];
    links = [];

    function traverse(node) {
      nodes.push(node);
      if (node.children && node._collapsed !== true) {
        node.children.forEach(child => {
          links.push({ source: node.id, target: child.id });
          traverse(child);
        });
      }
    }

    traverse(data);

    simulation.nodes(nodes);
    simulation.force('link').links(links);

    const link = g.selectAll('.link')
            .data(links, d => d.source.id + '-' + d.target.id);

    link.exit().remove();

    link.enter()
            .append('line')
            .attr('class', 'link')
            .merge(link);

    const node = g.selectAll('.node')
            .data(nodes, d => d.id);

    node.exit().remove();

    const nodeEnter = node.enter()
            .append('g')
            .attr('class', 'node')
            .call(drag(simulation));

    nodeEnter.append('circle')
            .attr('r', 20)
            .attr('fill', d => d._collapsed ? "#ccc" : "#69b3a2")
            .on('click', (event, d) => {
              if (d.children) {
                d._collapsed = !d._collapsed;
                update();
              }
              event.stopPropagation();
            });

    nodeEnter.append('text')
            .attr('dy', 4)
            .attr('x', 0)
            .attr('text-anchor', 'middle')
            .text(d => d.name);

    simulation.on('tick', () => {
      g.selectAll('.link')
              .attr('x1', d => d.source.x)
              .attr('y1', d => d.source.y)
              .attr('x2', d => d.target.x)
              .attr('y2', d => d.target.y);

      g.selectAll('.node')
              .attr('transform', d => `translate(${d.x},${d.y})`);
    });

    simulation.alpha(1).restart();
  }

  function drag(simulation) {
    function dragstarted(event, d) {
      if (!event.active) simulation.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    }

    function dragged(event, d) {
      d.fx = event.x;
      d.fy = event.y;
    }

    function dragended(event, d) {
      if (!event.active) simulation.alphaTarget(0);
      d.fx = null;
      d.fy = null;
    }

    return d3.drag()
            .on('start', dragstarted)
            .on('drag', dragged)
            .on('end', dragended);
  }

  update();
</script>

</body>
</html>
